'use strict';

const Browser = require('./browser');
const _ = require('nami-utils/lodash-extra.js');
const nu = require('nami-utils');
const nfile = require('nami-utils/file');
const ncrypt = require('nami-utils/crypto');
const path = require('path');
const templates = require('nami-utils/templates');
const Logger = require('nami-logger');
const Registry = require('./registry.js');
const VmContext = require('./vm_context.js');
const repl = require('repl');

/**
 * Allows packages to access already installed modules without having direct access to the manager
 * @class
 * @classdesc Handling an instance of the manager to packages would grant them too much power,
 * by using this class, we only allow them to obtain references to already installed modules.
 * @private
 */
class ModulesManager {
  constructor(namiManager) {
    this._manager = namiManager;
  }

  // We do not expose the full package, just a restricted minimum handler to it
  _getModuleHandler(pkg) {
    return pkg.getHandler();
  }
  getAllModules() {
    const result = {};
    const _this = this;
    _.each(this._manager.listPackages(), function(pkg) {
      result[pkg.id] = _this._getModuleHandler(pkg);
    });
    return result;
  }
  getRequiredModules(requiredModulesSpec) {
    // requiredModulesSpec can be either:
    // An array ['foo', 'bar']: Exposing later on 'foo' and 'bar' as $modules.foo and $modules.bar
    // A hash {'foo': 'myFoo', bar: 'myBar'}: Exposing later on 'foo' and 'bar' as $modules.myFoo and $modules.myBar
    // So we normalize here the spec to always be a hash (in the case of an array, a hash with keys===values)
    requiredModulesSpec = requiredModulesSpec || {};
    let requiredModules = null;
    if (_.isArray(requiredModulesSpec)) {
      requiredModules = {};
      _.each(requiredModulesSpec, id => {
        requiredModules[id] = id;
      });
    } else {
      requiredModules = requiredModulesSpec;
    }
    const result = {};
    const _this = this;
    _.each(requiredModules, function(as, module) {
      let pkg = null;
      try {
        pkg = _this._manager.search(module, {requestSingleResult: true});
      } catch (e) {
        throw new Error(`Error loading module requirements: ${e.message}`);
      }
      result[as] = _this._getModuleHandler(pkg);
    });
    return result;
  }
}

/**
 * Handles all the package-related operations
 * @class
 * @_private
 */
class Manager {
  /**
   * Constructs an instance of a Nami Manager
   * @param {object} [options]
   * @param {object} [options.logger=null] - External logger to use. If none is provided, a default one will be used
   * @param {string} [options.logLevel=error] - When no external logger is provided, initial log level to use in the
   * autogenerated logger
   * @param {string} [options.installationPrefix=/opt/bitnami] - Default installation prefix. This can be overriden
   * at a global level later oo or configured when installing packages
   * @param {string} [options.registryPrefix=~/.nami] - Registry database location. This path will be used to store
   * information about installed packages.
   * @param {string} [options.encryptionPassword] - If an encryption password is provided, sensible information will be
   * encrypted when using 'secure' serialization policy
   * @param {string} [options.serializationPolicy=secure] - Configures how packages will serialize sensible information
   * such as passwords. 'secure' will make them either
   * to not be serialized or to be serialized encrypted, depending on the 'encryptionPassword' property. 'plain' will
   * just write them in plain text, without any protection
   * @constructs NamiManager
   */
  constructor(options) {
    options = _.defaults(options || {}, {
      logger: null, logLevel: 'error',
      installationPrefix: '/opt/bitnami',
      encryptionPassword: null,
      serializationPolicy: 'secure',
      registryPrefix: '~/.nami'
    });

    this.exitCode = 0;
    // Default root directory where all packages will be installed
    this.installationPrefix = options.installationPrefix;

    this.logger = options.logger || new Logger({level: options.logLevel, silenced: true});

    // The registry takes care of managing the "database" of installed packages
    this._registry = new Registry({
      logger: this.logger, prefix: options.registryPrefix,
      encryptionPassword: options.encryptionPassword,
      serializationPolicy: options.serializationPolicy,
      modulesManager: new ModulesManager(this)
    });

    // Allow asking the manager for the registryPrefix without going over the _registry
    nu.delegate(this, {registryPrefix: 'prefix', reload: 'reload'}, this._registry);
  }
  /**
   * Look for a package given a certain semver specification (sample, sample@3.5...)
   * @param {string} searchTerm - Search specification, as a minimum, it should contain the id or name
   * of the component to look for. It can also include contain a semversion specification
   * (foo, sample@2.45, sample@ > 5.5 || < 4 ...).
   * @param {object} [options]
   * @param {boolean} [options.reload=false] - If enabled, do not use cached objects and peform a load from scratch
   * @param {string|string[]} [options.searchBy=[id,name]] - Methods used for searching in order. The first one to
   * succeed will skip the rest.
   * @param {object} [options.requestSingleResult=false] - If enabled, fail if more than one component is found.
   * It will also make the function return a single component instead of a list
   * @throws Will throw an error if no components were found
   * @throws Will throw an error if more than one components are found and 'requestSingleResult' was enabled
   * @returns {object|object[]} Depending on if requestSingleResult was requested or not, the found Componet or list
   * of Components
   * @example
   * // Looks for any version of a package
   * manager.search('foo');
   * @example
   * // Looks for a package with an specific version
   * manager.search('foo@6.7.3');
   * @example
   * // Looks for a package within a certain version range
   * manager.search('foo@">3.4 && < 5.4"');
   * @example
   * // Looks for a package considering only IDs (ignoring the name)
   * manager.search('foo', {searchBy: ['id']});
   * // Returns a list: [{name: 'foo-name', version: '1.2', ...}]
   * @example
   * // Looks for a package considering only IDs (ignoring the name). One result:
   * manager.search('foo', {searchBy: ['id'], requestSingleResult: true});
   * // Returns a single element: {name: 'foo-name', version: '1.2', ...}
   */
  search(searchTerm, options) {
    options = _.sanitize(options, {searchBy: ['id', 'name'], requestSingleResult: false, reload: false});
    const loadOptions = _.pick(options, ['reload']);
    const packages = this._registry.search(searchTerm, _.pick(options, 'searchBy'));
    const ids = _.pluck(packages, 'id');
    if (_.isEmpty(packages)) {
      throw new Error(`Cannot find any module matching the provided specification '${searchTerm}'`);
    }

    if (options.requestSingleResult) {
      if (ids.length > 1) {
        const printableResults = _.map(packages, data => `${data.name} ${data.version}`).join('\n');
        throw new Error(`Found multiple occurrences for the specified term '${searchTerm}': \n${printableResults}`);
      } else {
        return this._registry.loadPackage(_.first(ids), loadOptions);
      }
    } else {
      return _.map(ids, id => this._registry.loadPackage(id, loadOptions));
    }
  }
  findByID(pkg, options) {
    options = _.defaults(options || {}, {abortOnError: true});
    const obj = this._registry.loadPackage(pkg);
    if (obj === null && options.abortOnError) {
      throw new Error(`Cannot find package ${pkg}`);
    } else {
      return obj;
    }
  }
  // TODO: If we start allowing multiple versions of the same package we have to redefine how this is stored
  getPackageId(obj) {
    return obj.id;
  }
  _loadPackageFromDir(dir, options) {
    return this._registry.loadPackageFromDir(dir, options);
  }

  // TODO: Perform real checks here
  _validatePackageDirectory(pkgPath) {
    if (_.some(['nami.json', 'bitnami.json'], f => nfile.exists(
      path.join(pkgPath, f)
    ))) {
      return;
    } else {
      throw new Error(`Cannot load package '${pkgPath}'`);
    }
  }


  /**
   * List installed packages
   * @returns {object[]} A list of all installed packages
   * @example
   * // List all packages
   * manager.listPackages();
   */
  listPackages() {
    return this._registry.loadAllPackages({softSchemaValidation: true, abortOnError: false});
  }

  // This is juat a quick way for testing it may not make sense in production in its current state
  // Just documenting it as a reminder of its meaning until we improve/clean it
  /**
   * Refresh an already installed package metadata an JS scripts from a local package location. Useful for testing
   * @private
   * @param pkgLocation - Directory or zip file containing the package to refresh
   * @example
   * // Refresh already installed 'sample' updating its nami.json and JS scripts with those in path/to/sample
   * manager.refreshMetadata('path/to/sample')
   */
  refreshMetadata(pkg, options) {
    this.logger.info(`Refreshing ${pkg} metadata`);
    this._validatePackageDirectory(pkg);
    options.srcDirRoot = pkg;
    const obj = this._loadPackageFromDir(pkg, options);
    const pkgId = this.getPackageId(obj);

    const existingPackage = this.findByID(pkgId, {abortOnError: false});

    if (existingPackage === null) {
      throw new Error(`${pkgId} is not installed. It is not possible to update its metadata`);
    }
    this._registry.updateResources(existingPackage, obj);
  }

  /**
   * Inspect installer package metadata
   * @param pkg - Package reference. It can be its id or name, including an optional semver
   */
  inspectPackage(pkg) {
    return this.search(pkg, {requestSingleResult: true}).serializeData();
  }
  _populateObjectFromCmdFlags(obj, options) {
    options = _.sanitize(options, {validateRequiredAndNotProvidedOptions: true, args: [], hashArgs: {}});
    if (!_.isEmpty(options.hashArgs)) {
      // We will also validate 'args', which will validate required options so don't fail here just yet
      obj.parser.parseData(options.hashArgs, {force: true});
    }
    obj.parser.parse(options.args || [], {
      abortIfRequiredAndNotProvided: options.validateRequiredAndNotProvidedOptions
    });
    obj.validate({validateRequiredAndNotProvidedOptions: options.validateRequiredAndNotProvidedOptions});
  }
  /**
   * Initialize an already 'unpacked' package. It will look for a package in the 'unpacked' statate and
   * execute their postInstall
   * @param package - Package reference. It can be its id or name, including an optional semver.
   * @param {object} [options]
   * @param {boolean} [options.force=false] - If enabled, initialize the package even if fully installed
   * @param {string[]} [options.args=[]] - Extra arguments to pass on in raw format (as got from process.argv)
   * @param {object} [options.hashArgs=[]] - Extra arguments to pass on in hash format
   */
  initializePackage(pkg, options) {
    options = _.opts(options, {force: false, args: [], hashArgs: {}});

    const obj = this.search(pkg, {requestSingleResult: true});
    switch (obj.lifecycle) {
      case 'unpacked':
        this.logger.info(`Initializing ${pkg}`);
        break;
      case 'installed':
        if (options.force) {
          this.logger.warn(`Forcing initialization of package ${pkg}`);
        } else {
          throw new Error(`Package ${pkg} seems to be already fully installed`);
        }
        break;
      default:
        throw new Error(`Package cannot be initialized (incompatible lifecycle ${obj.lifecycle})`);
    }
    this._populateObjectFromCmdFlags(obj, _.pick(options, ['args', 'hashArgs']));
    try {
      obj.install({onlyPostInstall: true});
      this.logger.info(`${obj.name} successfully initialized`);
    } catch (e) {
      this.exitCode = 1;
      throw e;
    } finally {
      this._registry.update(obj);
    }
    return obj;
  }

  // Internal method that allows extra installation options: skipPostInstall and onlyPostInstall,
  // which are selected based on higher level commands
  _install(pkgLocation, options) {
    options = _.opts(options, {
      installPrefix: this.installationPrefix,
      skipPostInstall: false, onlyPostInstall: false,
      force: false, args: [], hashArgs: {}
    });
    pkgLocation = nfile.normalize(pkgLocation);
    if (!nfile.exists(pkgLocation)) throw new Error(`'${pkgLocation}' does not exists`);

    this.logger.info(`${options.skipPostInstall ? 'Unpacking' : 'Installing'} ${pkgLocation}`);
    this._validatePackageDirectory(pkgLocation);

    options.srcDirRoot = pkgLocation;
    options.initializing = true;

    const obj = this._loadPackageFromDir(pkgLocation, options);
    const pkgId = this.getPackageId(obj);
    const existingPackage = this._registry.getPkgData(pkgId, {abortOnError: false});
    if (existingPackage !== null) {
      if (options.force) {
        this.logger.warn(`Reinstalling package ${pkgId}`);
      } else {
        const isFullyInstalled = existingPackage.lifecycle === 'installed';
        throw new Error(`Package ${pkgId} seems to be ${isFullyInstalled ? 'already' : 'partially'} installed`);
      }
    }
    // Parse command line arguments
    // If we are not doing the post-install, don't throw errors
    // if required options are not provided
    const validateRequiredAndNotProvided = !options.skipPostInstall;
    this._populateObjectFromCmdFlags(
      obj,
      _.extend(_.pick(options, ['args', 'hashArgs']), {
        validateRequiredAndNotProvidedOptions: validateRequiredAndNotProvided
      })
    );
    if (!nfile.writable(obj.installdir)) {
      throw new Error(`The selected installdir '${obj.installdir}' is not writable by the current user`);
    }
    try {
      obj.install(options);
      this.logger.info(
        `${obj.name} successfully ${options.skipPostInstall ? 'unpacked' : 'installed'} into ${obj.installdir}`
      );
    } catch (e) {
      this.exitCode = 1;
      throw e;
    } finally {
      if (this._shouldRegisterComponent(obj)) {
        this._registry.register(obj);
      }
    }
    return obj;
  }
  _shouldRegisterComponent(obj) {
    // If lifecycle is null, it means it did not even reached the first
    // millestone (preInstallChecks) so we do not want to register it
    return obj.lifecycle !== null;
  }
  /**
   * Unpacks a package. It will basically perform the first steps of the installation, skipping from the
   * postInstallation onwards. The package will be registered with 'unpacked' status, and will need to be
   * initialized to be usable
   * @param pkgLocation - Directory or zip file containing the package to install
   * @param {object} [options]
   * @param {string} [options.installPrefix] - If provided, it will override the global installation prefix
   * @param {boolean} [options.force=false] - Force re-unpacking of already unpacked packages
   * @param {string[]} [options.args=[]] - Extra arguments to pass on in raw format (as got from process.argv)
   * @param {object} [options.hashArgs=[]] - Extra arguments to pass on in hash format
   */
  unpack(pkgLocation, options) {
    options = _.sanitize(options, {installPrefix: this.installationPrefix, args: [], hashArgs: {}, force: false});
    return this._install(pkgLocation, _.extend(options, {skipPostInstall: true}));
  }

  /**
   * Installs a package
   * @param pkgLocation - Directory or zip file containing the package to install
   * @param {object} [options]
   * @param {string} [options.installPrefix] - If provided, it will override the global installation prefix
   * @param {boolean} [options.force=false] - Force reinstallation of packages
   * @param {string[]} [options.args=[]] - Extra arguments to pass on in raw format (as got from process.argv)
   * @example
   * // Installs package providing arguments directly from process.argv
   * manger.install('/path/to/package', {args: process.argv.slice(1)})
   * // Installs package providing password in raw format
   * manger.install('/path/to/package', {args: ['--password=foo']})
   */
  install(pkgLocation, options) {
    options = _.sanitize(options, {installPrefix: this.installationPrefix, force: false, args: [], hashArgs: {}});
    return this._install(pkgLocation, options);
  }


  /**
   * Tests a previously installed package
   * @param pkg - Package reference. It can be its id or name, including an optional semver
   * @param callback - Function to execute after the tests finish
   * @param {object} [options]
   * @param {string} [options.testDir] - Directory containing the test files to execute.
   * Defaults to the package 'test' directory
   * @param {string[]} [options.exclude=[]] - List of patterns used to exclude test files from execution
   * @param {string[]} [options.include=['*']] - List of patterns used to include test files in the execution
   * @param {regexp} [options.grep=null] - If provided, only execute tests maching this pattern
   */
  test(pkg, callback, options) {
    const obj = this.search(pkg, {requestSingleResult: true});
    this.logger.info(`Testing ${pkg}`);
    try {
      obj.runTests(callback, options);
    } catch (e) {
      this.exitCode = 1;
      throw e;
    }
  }

  /**
   * Uninstalls a previously installed package
   * @param pkg - Package reference. It can be its id or name, including an optional semver
   * @throws Will throw an error trying to uninstall a package as a regular user previously installed as root
   */
  uninstall(pkg) {
    const obj = this.search(pkg, {requestSingleResult: true});
    if (obj.installedAsRoot && !nu.os.runningAsRoot()) {
      throw new Error(`This package was installed as root. Refusing to uninstall without admin privileges`);
    }
    this.logger.info(`Uninstalling ${pkg}`);
    try {
      obj.uninstall();
    } catch (e) {
      this.exitCode = 1;
      throw e;
    } finally {
      if (obj.lifecycle === 'uninstalled') {
        this._registry.unregister(obj);
      }
    }
  }

  _getVmContextData() {
    return {
      require: require,
      $modules: new ModulesManager(this).getAllModules(), $hb: templates, $manager: this,
      $Browser: Browser,
      $util: nu.util, $file: nu.file, $os: nu.os, $build: nu.build,
      $net: nu.net,
      $crypt: ncrypt
    };
  }

  /**
   * Evals filename with all the nami packages populated
   * @returns {*} The result of the last line executed
   * @example
   * // file.js contains 7 + 4
   * manager.evalFile('file.js');
   * // Returns 11
   */
  evalFile(script, options) {
    options = _.opts(options, {package: null});
    if (options.package) {
      const obj = this.search(options.package, {requestSingleResult: true});
      return obj.evalFile(script);
    } else {
      const context = new VmContext(this._getVmContextData());
      return context.evalFile(script, {displayErrors: false});
    }
  }

  /**
   * Opens an intactive console with a global or a given package environment
   * @param [package] - If provided, the opened console will also include a reference to the package as $app.
   */
  console(pkg) {
    if (pkg) {
      const obj = this.search(pkg, {requestSingleResult: true});
      obj.console({globals: {$manager: this}});
    } else {
      const contextData = this._getVmContextData();
      const context = repl.start('nami> ').context;
      _.extend(context, contextData);
    }
  }
}

module.exports = Manager;
